Skip to content

fix(block-editor): apply links to a selected image instead of replacing it#36368

Merged
rjvelazco merged 10 commits into
mainfrom
issue-36361-block-editor-cannot-add-a-link-to-an-image-image-properties-menu-not-accessible
Jun 30, 2026
Merged

fix(block-editor): apply links to a selected image instead of replacing it#36368
rjvelazco merged 10 commits into
mainfrom
issue-36361-block-editor-cannot-add-a-link-to-an-image-image-properties-menu-not-accessible

Conversation

@rjvelazco

@rjvelazco rjvelazco commented Jun 30, 2026

Copy link
Copy Markdown
Member

Proposed Changes

Fixes #36361 — in the new block editor, selecting an image and clicking the toolbar Link button deleted the image and replaced it with a text link. The dotImage node already supports href/target end-to-end (it parses a wrapping <a> and re-wraps the <img> on render); the break was purely in the UI flow, which always routed through the text-link insert path.

What changed

  • Toolbar Link button is image-aware — when a dotImage node is selected, the link popover opens in image-link mode (URL + "Open in new tab" only; Text/Advanced fields hidden) and applies the link via updateAttributes('dotImage', { href, target }), so the image is preserved instead of replaced.
  • Remove link — a trash-can action appears when editing an image that already has a link, clearing href/target.
  • Click behavior — clicking a linked image no longer opens the text-link editor; it selects the image node (the link is edited from the toolbar) and prevents the anchor from navigating.
  • Layout fixes — eliminated the vertical jump/extra space when a link wraps the image: zero the inline-block baseline descender gap, and zero the Tailwind Typography img margin that reappears once the image is no longer a direct <figure> child.
  • Active state — the toolbar Link button now highlights for linked images (isImageLinked), mirroring isLink for text links.

The text-link flow is unchanged. No dotImage node name or stored-content shape changed — links round-trip through the existing href/target attributes, so no data migration is required.

Files

  • link-popover.component.{ts,html} — image-link mode, Remove action
  • toolbar.component.{ts,html} + editor-toolbar.store.ts — image detection, active state
  • editor-chrome-click.ts — don't open the dialog on linked-image clicks
  • editor.component.css — layout/margin fixes
  • editor-popover.service.tsisImageLink payload flag
  • Language.propertiesdot.block.editor.dialog.link.remove

Testing

  • pnpm nx lint new-block-editor
  • tsc --noEmit on the lib ✓
  • Manual: insert image → select → Link button opens URL-only popover → Save keeps the image wrapped in <a> (not replaced); re-select shows active Link button + trash to remove; no layout jump toggling the link; text-link flow unchanged.

Video

video.mov

🤖 Generated with Claude Code

This PR fixes: #36361

…ng it (#36361)

In the new block editor, selecting an image and clicking the toolbar Link
button deleted the image and replaced it with a text link. The image node
already supports href/target attributes end-to-end; the break was purely in
the UI flow, which always routed through the text-link insert path.

- Toolbar Link button now detects a selected dotImage and opens the link
  popover in image-link mode (URL + open-in-new-tab only; no Text/Advanced),
  applying the link via updateAttributes('dotImage') so the image is preserved.
- Add a Remove-link (trash) action when editing an image that already has a link.
- Clicking a linked image no longer opens the text-link editor; it selects the
  image node (link is edited from the toolbar) and prevents navigation.
- Fix layout jump/extra margin when a link wraps the image: zero the inline-block
  baseline gap and the Tailwind Typography img margin that returns once the image
  is no longer a direct figure child.
- Toolbar Link button reflects an active state for linked images (isImageLinked),
  mirroring isLink for text links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 1m 34s —— View job


Rollback Safety Analysis

  • Read rollback-unsafe categories reference
  • Get full PR diff
  • Analyze diff against all unsafe categories
  • Apply rollback safety label

Result: ✅ Safe to Roll Back

All 13 changed files are frontend (Angular/TypeScript) and one Language.properties entry. The diff contains no database migrations, no Elasticsearch mapping changes, no Java backend changes, and no REST/GraphQL API contract changes. Here is the full category check:

Category Verdict Reason
C-1 Structural Data Model ✅ Safe No runonce tasks, no DB schema changes
C-2 ES Mapping Change ✅ Safe No ES mapping code touched
C-3 Content JSON Model Version ✅ Safe No CURRENT_MODEL_VERSION bump, no ImmutableContentlet change
C-4 DROP TABLE / DROP COLUMN ✅ Safe No DDL of any kind
H-1 One-Way Data Migration ✅ Safe No data backfill or destructive UPDATE
H-2 RENAME TABLE / COLUMN ✅ Safe No renames
H-3 PK Restructuring ✅ Safe No PK changes
H-4 New ContentType Field Type ✅ Safe No new field types registered
H-5 Storage Provider Change ✅ Safe No storage provider config changes
H-6 DROP PROCEDURE / FUNCTION ✅ Safe No stored procedures touched
H-7 NOT NULL column without default ✅ Safe No schema changes
H-8 VTL Viewtool Contract ✅ Safe No viewtool classes modified
M-1 Column Type Change ✅ Safe No DDL
M-2 Push Publishing Bundle Format ✅ Safe No bundle XML changes
M-3 REST/GraphQL API Contract ✅ Safe No REST endpoint or response shape changes
M-4 OSGi Interface Change ✅ Safe No OSGi interfaces modified

Summary: The PR is purely UI — Angular component logic and templates for the block editor link popover, image/video TipTap node extensions, CSS layout fixes, and one i18n key addition. ProseMirror node attribute additions (mimeType/width/height/orientation on dotVideo, href/target on dotImage) affect only the Angular/TipTap layer; they are already stored as optional attributes in the block editor JSON and the server-side schema is unchanged. Rolling back to the previous release leaves the Java backend, database, and Elasticsearch index completely untouched.

Label added: AI: Safe To Rollback

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

🤖 dotBot Review (Bedrock)

Reviewed 13 file(s); 12 candidate(s) → 7 confirmed, 2 uncertain (unverified, kept for review).

⚠️ Coverage capped: 0 file(s) + 2 lower-severity candidate(s) skipped (limits: 40 files, 12 candidates).

Confirmed findings

  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/components/link-popover/link-popover.component.html:78 — Stale displayText value when switching link types
    The displayText form control retains its previous value when switching from text-link to image-link mode. While the UI hides the field via *ngIf, the underlying form control isn't reset. This could lead to accidental inclusion of displayText in image-link submissions via displayText?.value, as the form submission logic doesn't account for the control's visibility state.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/components/link-popover/link-popover.component.ts:453 — Using null instead of undefined for attribute removal in ProseMirror
    In link-popover.component.ts line 453, setting href: null and target: null when removing a link may not properly clear the attributes. ProseMirror typically removes attributes when set to undefined. Using null could leave the attributes present with null values, potentially causing invalid href attributes in rendered HTML. The correct approach is to use undefined to ensure attributes are removed from the node.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/editor.utils.ts:163 — Potential attribute key collision in video data object
    The data object explicitly sets properties like 'video', 'controls', etc., then spreads videoMetaAttrsFromContentlet. If the contentlet-derived attributes contain same keys, they'll overwrite the hardcoded values. Spread order should be reversed to let explicit properties take precedence.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/image.extension.ts:189 — Stale node position reference in async handler
    The code calls getPos() after await onClose(), risking an invalid position if document changes occurred while the popover was open. Node positions should be captured synchronously before async operations to ensure validity.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/image.extension.ts:189 — Missing position validity check before selection
    The code sets the selection at position 'pos' without validating if it's within the document's bounds. After modifying the transaction with replaceSelectionWith, the original 'pos' might become invalid if the document structure changes. This could lead to runtime errors when attempting to set an invalid TextSelection position. The code should ensure 'pos' is within 0 <= pos <= tr.doc.nodeSize before creating the selection.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/services/dot-upload.service.ts:75 — Missing properties in DotCMSContentlet usage
    The code switched from PublishedAsset to DotCMSContentlet type but there's no evidence DotCMSContentlet contains required 'asset' or 'identifier' properties. Grep shows DotCMSContentlet is defined in dotcms-js/core as having 'identifier' but no 'asset' property, which could lead to runtime errors when accessing 'asset'.
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/services/dot-upload.service.ts:65 — Unsafe type assertion of API response without validation
    The code directly asserts dotAssets[0] as Record<string, DotCMSContentlet> without validation. While the current API might return compatible data, this hard-coded type assertion creates a maintenance risk if the response structure changes. A type guard or runtime validation would make this more robust against API changes.

🔎 Uncertain (could not confirm or disprove — review manually)

  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts:53 — Missing test coverage for isImageLinked signal behavior tracking image href presence
    Missing test coverage for isImageLinked signal behavior tracking image href presence
  • 🟡 Medium core-web/libs/new-block-editor/src/lib/editor/extensions/nodes/video.extension.ts — Added video node attributes lack test coverage for HTML serialization/deserialization
    Added video node attributes lack test coverage for HTML serialization/deserialization

us.deepseek.r1-v1:0 · Run: #28463711457 · tokens: in: 96490 · out: 27973 · total: 124463 · calls: 36 · est. ~$0.281

…top layout jump (#36361)

Once the <a> wraps the image, the anchor (not the img) is the inline-level box
in the figure's formatting context, so it sits on the baseline and the browser
reserves font-descender space below it (~12px). Align the anchor to the top too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wrapping <a> made the browser treat clicks as link interactions, so
ProseMirror needed several clicks before it node-selected the image and showed
its toolbar options. Select the node on the first mousedown from the image
node view and suppress the anchor's default behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o-an-image-image-properties-menu-not-accessible
@rjvelazco

Copy link
Copy Markdown
Member Author

@claude review this changes, pretty please

@claude

claude Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 1m 21s —— View job


Code Review

  • Gather context and understand the request
  • Read all 10 changed files
  • Analyze for bugs, correctness, and best practices
  • Post review findings

Overall Assessment

This is a clean, well-scoped fix. The core approach is correct: routing the link toolbar button through updateAttributes('dotImage', ...) instead of the text-link insert path is exactly the right TipTap idiom, and the round-trip through href/target on the node's renderHTML/addNodeView is already proven. The layout fixes for the inline-block baseline gap and the Tailwind Typography margin regression are also correct. No data-migration is needed.

Below are the issues I found, from most to least significant.


🔴 Bug: renderHTML writes href="" when href is null

File: extensions/nodes/image.extension.ts:143

const anchorAttrs: Record<string, string> = { href };

When href is null (the default), this emits <a href="null">…</a> in the serialized HTML. The existing guard on line 145 (const inner = href ? ['a', anchorAttrs, img] : img) prevents the anchor being rendered at all, so this doesn't cause a visible bug today. But if renderHTML is ever called on a node with href: null directly (e.g. from a custom serializer or copy/paste), the wrong attribute object gets passed into TipTap's merge. Worth guarding:

const anchorAttrs: Record<string, string> = {};
if (href) anchorAttrs['href'] = href;
if (target) anchorAttrs['target'] = target;
const inner = href ? ['a', anchorAttrs, img] : img;

🟡 Missing test coverage: onRemoveImageLink

The bot flagged this incorrectly as "missing implementation" — onRemoveImageLink is implemented at link-popover.component.ts:433. But there are zero tests for it. The method does the right thing (updateAttributes + close()), but given this is the first time the remove-link path exists, a minimal spec covering: (a) updateAttributes called with { href: null, target: null }, and (b) manager.close() called, would prevent regressions.


🟡 Image-link edit state doesn't pre-populate openInNewTab

File: toolbar.component.ts:421–425

this.popovers.openLink(() => btn.getBoundingClientRect(), {
    isImageLink: true,
    initialValues: attrs['href']
        ? { href: attrs['href'], target: attrs['target'] ?? null }
        : undefined
});

The initialValues includes target, but the form's openInNewTab control is only set from initialValues.target in the pre-populate effect (values.target === '_blank'). Trace the path:

  1. Image has target="_blank"attrs.target = '_blank'
  2. initialValues.target = '_blank' is passed correctly
  3. The pre-populate effect reads values.target === '_blank' → sets openInNewTab: true

This actually works. But if attrs['target'] is null (no target attribute at all), the ?? null fallback is fine. No bug — just worth a comment clarifying that target flows into openInNewTab via the existing effect.


🟡 ::ng-deep in new CSS (pre-existing pattern, but worth flagging)

File: editor.component.css:244

The PR adds two new ::ng-deep blocks for the linked-image layout fix. ::ng-deep is deprecated in Angular but this file already uses it pervasively (it's the established pattern for styling ProseMirror's DOM from the Angular component). The new rules follow the existing pattern correctly — no change needed, but see Angular deprecation note.


🟡 mousedown + event.preventDefault() on the remove button blocks keyboard access

File: link-popover.component.html:187

(mousedown)="$event.preventDefault(); onRemoveImageLink()"

Using mousedown + preventDefault is the correct pattern for toolbar buttons that must not blur the editor (consistent with every other button in this file). However, the <button> does not have a (click) handler as a fallback, so keyboard users who Tab to this button and press Enter/Space will activate the click event, not mousedown, and nothing will happen. The other destructive buttons in this file (Cancel, Insert) use mousedown on buttons where preventing editor blur is the purpose — but onRemoveImageLink closes the popover and returns focus to the editor anyway, so a (click) handler would actually be fine here:

(click)="onRemoveImageLink()"

Or add both:

(mousedown)="$event.preventDefault()" (click)="onRemoveImageLink()"

🟢 Minor: target attribute not cleared on renderHTML when null

File: extensions/nodes/image.extension.ts:144

const anchorAttrs: Record<string, string> = { href };
if (target) anchorAttrs['target'] = target;

(Fixing the href: null issue above would also naturally express this more clearly.) The current code already conditionally sets target — this part is fine.


🟢 No confirmation before removing the link

The bot flagged this as a concern — I don't share it. A trash icon for a single attribute (not content deletion) is a well-established pattern and the action is undoable via Ctrl+Z. No confirmation dialog needed here.


Summary

Severity Issue File Line
🔴 Bug (latent) { href } spreads null into anchorAttrs image.extension.ts 143
🟡 Missing tests onRemoveImageLink has no spec link-popover.component.ts 433
🟡 A11y Remove button only responds to mousedown, not keyboard click link-popover.component.html 187

The href: null spread is latent (current guard prevents it rendering), but it should be fixed before it causes confusion. Fix this →

Comment thread core-web/libs/new-block-editor/src/lib/editor/editor-chrome-click.ts Outdated
@rjvelazco rjvelazco changed the title fix(block-editor): apply links to a selected image instead of replacing it (#36361) fix(block-editor): apply links to a selected image instead of replacing it Jun 30, 2026
- image.extension.ts: build anchorAttrs conditionally so a null href never
  serializes as href="null" (latent issue flagged in review).
- link-popover.component.html: make the Remove-link button keyboard-accessible
  by splitting into (mousedown) preventDefault + (click) handler, so Enter/Space
  activate it without blurring the editor or double-firing on mouse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#36361)

Gate the link-navigation preventDefault on the *clicked* element being an image
(within the anchor) rather than any descendant img, so a text click in a mixed
anchor still routes to the text-link editor. Addresses PR review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rjvelazco

Copy link
Copy Markdown
Member Author

Review dispositions

Thanks for the automated reviews. Summary of how each finding was handled:

Addressed

  • Overly broad click prevention (editor-chrome-click.ts) — tightened in 814ba6c to gate the link-navigation preventDefault() on the clicked element being the image (event.target.closest('img') within the anchor) rather than any descendant img, so a text click in a mixed anchor still routes to the text-link editor.
  • href: nullhref="null" in renderHTML (image.extension.ts) — anchorAttrs now built conditionally so a null/absent href never serializes.
  • Remove-link button keyboard a11y (link-popover.component.html) — split into (mousedown)="$event.preventDefault()" + (click)="onRemoveImageLink()" so Enter/Space activate it without blurring the editor or double-firing on mouse.

False positives (no change)

  • Missing onRemoveImageLink() implementation — the method is implemented at link-popover.component.ts:433; the handler exists and is wired. (The Bedrock/Claude review also corrected this.)
  • Image href lacks protocol validation / XSS — image and text links share the same validation. onInsert() returns early on this.form.controls.href.invalid before the image branch, and href carries [Validators.required, linkHrefValidator]. isValidLinkHref allows only http/https/mailto/tel + anchor/relative paths; javascript: parses to protocol === 'javascript:', fails the allowlist, and the form is invalid → nothing is applied. There is no path that sets an image href outside this validated form.

Acknowledged, no change

  • ::ng-deep without justification (editor.component.css) — ::ng-deep is the established pattern throughout this file for styling ProseMirror's DOM from the Angular component; the new rules follow it and carry comments explaining the layout rationale.

Note on tests

No spec was added for onRemoveImageLink because libs/new-block-editor/ intentionally has no *.spec.ts files yet (project convention); changes here are verified via nx lint + tsc.

The trash (Remove link) button now shows whenever editing an existing link,
not just images. Generalized onRemoveImageLink → onRemoveLink: images clear
href/target; text links unset the link mark over its full range. Gated on
canRemoveLink (existing href for images, linkEl for text) so it never shows
while creating a new link over selected text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n attrs (#36361)

The new editor's dotVideo node only declared src/title/data, so the legacy
block-editor's mimeType/width/height/orientation attributes were silently
dropped when an old document was re-saved (TipTap discards undeclared attrs),
and server-side rendering that reads width/height would lose them. Re-declare
the four attributes (parse + conditional render) for round-trip parity with the
legacy node; title stays as an additive extra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o-an-image-image-properties-menu-not-accessible
…on from contentlet (#36361)

Mirrors the legacy getVideoAttrs but resolves file metadata via the canonical
getFileMetadata helper (handles both metaData and assetMetaData shapes) instead
of reading assetMetaData only. New shared videoMetaAttrsFromContentlet() is used
on both contentlet-bearing insert paths: the dotCMS picker
(insertDotVideoFromContentlet) and the drag-drop upload (uploadVideo →
UploadedVideo). URL-paste has no contentlet, so those attrs stay null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rjvelazco rjvelazco added this pull request to the merge queue Jun 30, 2026
@mergify

mergify Bot commented Jun 30, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

Merged via the queue into main with commit 58cd429 Jun 30, 2026
69 of 73 checks passed
@rjvelazco rjvelazco deleted the issue-36361-block-editor-cannot-add-a-link-to-an-image-image-properties-menu-not-accessible branch June 30, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Block Editor: cannot add a link to an image (image properties menu not accessible)

3 participants